Wie lange hält mein Akku?

Bei der Entwicklung eines Gerätes mit Lithium-Ionen-Akku ist die Nachhaltigkeit nicht belanglos. Daher stellt sich früher oder später die Frage: Wie lange bleibt die Zelle einsatzfähig?

Bei dieser Frage soll Abhilfe geschaffen werden, und zwar mit einer datenbasierten Veranschaulichung. Dabei wird der Blick zunächst auf die historische Entwicklung der Kapazität und des Innenwiderstand gerichtet.

Ein simpel gestaltetes, lineares Regressionsmodell simuliert den weiteren Verlauf.

Die Grafiken bieten zwei interaktive Elemente:

  • Slider: Der Slider dient dazu den Innenwiderstand wunschgemäss anzupassen.
  • Dropdown: Im Dropdown-Menü kann man die gewünschten Betriebsbedingungen wählen wie etwa die Temperatur oder den Strom.

Als Ergebnis sieht man eine simulierte Vorhersage, wie lange eine Zelle nutzbar bleibt, bevor sie ihre Grenzwerte überschreitet. Hinzu kommt die geschätzte Wärmeentwicklung der Zelle, welche auf Basis eines Newtonschen Erwärmungsmodell basiert.

Hierbei ist besonders wichtig, bei welchem Zyklus die Zelle ihre höchste Betriebstemperatur erreicht. Dies spielt eine wichtige Rolle für die Alterung, die Sicherheit sowie die Effizienz der Lithium-Ionen-Akkuzelle.


Jede Zeile steht für eine Testgruppe des NASA-Datasets.
Batterien → ID-Codes der geprüften Zellen
Temperatur → Umgebung während des Zyklierens
Entladestrom → konstanter Strom bzw. Pulsprofil

Wähle im Dropdown später genau diese Gruppen aus, um die dazugehörigen Degradations- und Temperaturkurven anzuzeigen.

Gruppe Batterien Temperatur (°C) Entladestrom
Low Temp, 1A B0045, B0046, B0047, B0048 4 1 A
Low Temp, 2A B0053, B0054, B0055, B0056 4 2 A
Room Temp, 2A B0005, B0006, B0007, B0018 24 2 A
Room Temp, PWM 50% 4A B0025, B0026, B0027, B0028 24 2 A
High Temp, 4A B0029, B0030, B0031, B0032 44 4 A

Die zugrunde liegenden Daten stammen aus einem öffentlich zugänglichen Datensatz des Nutzers “astro_pat” (Link zu Kaggle). Messreihen zur Degradation von Lithium-Ionen-Zellen und bildet die Grundlage für sämtliche Veranschaulichungen verwendet. Weitere Details zur Auswahl und zur Bereinigung der Daten sind im Tab «Datenfilterung» ersichtlich.

Weitere Details zur Auswahl und Bereinigung der Daten findest du im Abschnitt Datenfilterung.

Das eingesetzte Prognosemodell kombiniert eine robuste lineare Regressionsmethode mit einem vereinfachten thermischen Modell nach Newton, um sowohl den Kapazitätsverfall als auch die Temperaturentwicklung von Lithium-Ionen-Zellen im Zeitverlauf abzuschätzen.

  1. Theil‑Sen Regression
    • Es wird eine Gerade durch 80% der gemessenen Kapazitäts- und Innenwiderstandswerte gelegt.
    • Mit Hilfe eines Sliders kann der Start-Innenwiderstand angepasst werden, und über das Dropdown wird das jeweilige Belastungsprofil (Temperatur & Entladestrom) gewählt.
    • Die Regressionsgerade liefert eine Schätzung, bei welchem Zyklus (bzw. Lebensdauer-Punkt) die Zelle einen definierten End-of-Life-Wert (z. B. 30 % Kapazitätsverlust) erreicht.
  2. Newtonsches Erwärmungsmodell
    • Parallel dazu wird die Temperaturentwicklung der Zelle mithilfe eines vereinfachten thermischen Modells berechnet:

      Diese Gleichung beschreibt, wie sich die Temperaturdifferenz zur Umgebungstemperatur im Verlauf eines Entladevorgangs verändert. Die Parameter \(\alpha\) , \(A\) und \(\tau\) repräsentieren dabei den Wärmeübergangskoeffizienten (empirisch), die effektive Zelloberfläche und die thermische Zeitkonstante.

      Das Modell liefert eine Schätzung, bei welchem Zyklus die maximale Betriebstemperatur erreicht wird, ein zentraler Aspekt in Bezug auf Alterung, thermische Sicherheit und Wirkungsgrad.

Die Kombination beider Ansätze ermöglicht eine einfache, aber nachvollziehbare Vorhersage: Zum einen, wann die Kapazität unter ein kritisches Niveau fällt, zum anderen, wie sich die Zelltemperatur im Laufe des Betriebs entwickelt.

Bevor die Daten für die Erstellung von Prognosen verwendet werden konnten, war eine systematische Analyse und anschliessender Bereinigung der Daten erforderlich. Zur Förderung der Nachvollziehbarkeit, werden untenstehend die zentralen Verarbeitungsschritt erläutert:

  1. Erkennung und Bereinigung von «Ausreissern» (stark vom übrigen Datenverlauf abweichende Einzelwerte): Der Fokus lag hierbei war auf Zellmessungen mit ungewöhnlich hohen oder niedrigen Impedanzwerten („Anomalous data“). Um zu vermeiden, dass diese Werte die Aussagekraft der Analyse verfälschen oder beeinflussen, wurden sie konsequent ausgeschlossen.

  2. Prüfung auf Vollständigkeit:
    Für eine valide Auswertung konnten nur Daten berücksichtigt werden, welche in allen relevanten Aspekten vollständig vorlagen. Konkret bedeutet dies, es wurden nur Werte für «Batterien» berücksichtigt, sofern «Temperature (°C)» und «Charge/Discharge Protocol» vorhanden war. Unvollständige Einträge wurden verworfen.

  3. Selektion relevanter Testgruppen:
    Zunächst wurden jene Testgruppen definiert, welche für die Analyse relevant sind (siehe hierzu im Tab Test-Bedingung). Die Zuteilung erfolgte anhand eines Abgleichs der Werte unter der Spalte «Discharge Protocol». Nur diese Testgruppen wurden weiterverarbeitet, die übrigen Protokolle wurden ausgeklammert. Grund hierfür sind uneinheitliche Testbedingungen, die zu nicht repräsentativen Einzelwerten führen.

  4. Der Faktor Zeit:
    Es wurden nur Messungen berücksichtigt, bis die Zelle den definierten End-of-Life-Punkt («EOL Criteria») erreicht hat. Zyklen (einmal Laden und Entladen) mit Inkonsistenzen wie etwa fehlendem Zeitstempel wurden aus der Serienberechnung entfernt, um eine saubere Trendlinie zu gewährleisten.

  5. Normalisierung zwecks Vergleichbarkeit:
    Für jede Zelle wurde der Innenwiderstand und die Kapazität auf den Ausgangszustand (Zyklus 0) normiert, damit die Daten über verschiedene Testbedingungen hinweg vergleichbar sind.

Durch die beschriebenen Filterungsschritte liegen nun bereinigte und konsistente Teildatensätze vor, die als Grundlage für die anschliessende Regressionsanalyse sowie die interaktiven Visualisierungselemente (Slider und Dropdown-Menü) dienen. Auf diese Weise wird gewährleistet, dass die Prognoseergebnisse auf vergleichbarem Datenmaterial beruhen und nicht durch Ausreisser oder fehlende Werte verzerrt werden.


Interaktive Darstellung der Zellentwicklung

  1. Was ist ersichtlich?

    • Obere Grafik: Dargestellt ist der zeitliche Verlauf des Innenwiderstands, der im Verlauf der Zyklen einen ansteigenden Trend aufweist.
    • Untere Grafik: Die untere Darstellung zeigt die Entwicklung der Kapazität, welche mit zunehmender Zyklenzahl kontinuierlich abnimmt.
  2. Interaktive Funktionen

    Element Aktion Effekt
    🔍 Mouse-Over Zeige auf eine Linie Exakte Zyklus-/Messwerte als Tooltip
    📑 Legende-Klick Einfach-Klick: Linie ein/aus
    Doppel-Klick: gewünschte Linie isolieren
    Fokus auf bestimmte Zellen
    📂 Dropdown Testgruppe wählen Zeigt Kurven des gewählten Belastungsprofils
  3. Warum Zyklen statt Zeit?
    Die Zellalterung wird primär durch Nutzung bestimmt. Ein Zyklus (Laden + Entladen) sagt daher mehr über Degradation aus als reine Kalendertage. Zyklen stellen daher eine geeignetere Referenzgrösse zur Bewertung des Alterungsverlaufs dar.

  4. Interpretationshinweis
    Achte auf den Punkt, an dem die Kapazitätskurve 30 % unter Nennwert unter den ursprünglichen Nennwert fällt. Dieser Schwellenwert markiert typischerweise das Erreichen des End-of-Life-Zustands der Zelle.

Code
import numpy as np
import pandas as pd
from IPython.display import display
import re
from datetime import datetime
import plotly.graph_objs as go

df = pd.read_pickle("../1_Data/merged_dataset.pkl")

import os, pickle

model_path = "../1_Data/degradation_model.pkl"

if os.path.exists(model_path):
    #print(f"INFO: Lade Modellcache {model_path}")
    with open(model_path, "rb") as f:
        cache = pickle.load(f)
    agg_df  = cache["agg_df"]
    cap_agg = cache["cap_agg"]
    skip_training = True
else:
    #print("INFO: Kein Modellcache, Training wird ausgeführt …")
    skip_training = False
# Berechne Kapazität aus Rohdaten (Discharge)
# Stelle sicher, dass Current_load und Battery_current numerisch sind
df["Current_load"] = pd.to_numeric(df["Current_load"], errors="coerce")
df["Battery_current"] = pd.to_numeric(df["Battery_current"], errors="coerce")
# Robuster Discharge‑Filter:
dis_mask = (
    df["type"].str.contains("discharge", case=False, na=False)
) | (
    pd.to_numeric(df["Current_load"], errors="coerce") < -0.01
) | (
    pd.to_numeric(df["Battery_current"], errors="coerce") < -0.01
)
df_discharge_raw = df[dis_mask].copy()
df_discharge_raw["dt"] = (
    df_discharge_raw
    .sort_values(by=["battery_id", "test_id", "Time"])
    .groupby(["battery_id", "test_id"])["Time"]
    .diff()
    .fillna(0)
)
# Berechne Energie ausschliesslich aus Current_load mit angenommener Nominalspannung 3.7V
df_discharge_raw["energy_Wh"] = (
    df_discharge_raw["Current_load"].abs()
    * 3.7
    * df_discharge_raw["dt"]
    / 3600
)
# Kapazität in Ah pro Zyklus (Energy Wh / Nominalspannung ~3.7V)
cap_per_cycle = (
    df_discharge_raw
    .groupby(["battery_id", "test_id"])["energy_Wh"]
    .sum()
    .reset_index()
    .rename(columns={"energy_Wh": "Capacity_Ah"})
)
cap_per_cycle["Capacity_Ah"] = cap_per_cycle["Capacity_Ah"] / 3.7
# Merge Kapazitätswerte zurück in den Haupt-DataFrame
df = df.merge(
    cap_per_cycle[["battery_id", "test_id", "Capacity_Ah"]],
    on=["battery_id", "test_id"],
    how="left"
)
# Debug-Ausgabe, um den Merge-Erfolg zu prüfen
#print("DEBUG: Nicht-NA-Anzahl in 'Capacity_Ah' nach Berechnung:", df["Capacity_Ah"].notna().sum())
# DataFrame für Re-Berechnung sichern
df_for_re = df.copy()

# Lade separate Kapazitätsdaten und merge mit den Impedanzdaten
# (Passe den Pfad "../1_Data/capacity_dataset.pkl" an, falls nötig)
try:
    df_cap = pd.read_pickle("../1_Data/capacity_dataset.pkl")
    df_cap["Capacity"] = pd.to_numeric(df_cap["Capacity"], errors="coerce")
except FileNotFoundError:
    #print("WARNUNG: capacity_dataset.pkl nicht gefunden. Kapazitätsdaten werden übersprungen.")
    df_cap = pd.DataFrame(columns=["battery_id", "test_id", "Capacity"])
# Sicherstellen, dass die benötigten Spalten existieren und numerisch sind
# Merge auf Basis von battery_id und test_id
df = df.merge(
    df_cap[["battery_id", "test_id", "Capacity"]],
    on=["battery_id", "test_id"],
    how="left",
    suffixes=("", "_cap")
)
# Debug-Ausgabe, um den Merge-Erfolg zu prüfen
##print("DEBUG: Nicht-NA-Anzahl in 'Capacity_cap' nach Merge:", df["Capacity_cap"].notna().sum())
df_for_re["Re"] = pd.to_numeric(df_for_re["Re"], errors="coerce")
df_for_re = df_for_re[(df_for_re["Re"] > 0.03) & (df_for_re["Re"] < 0.15)]

# Testgruppen definieren
gruppen = {
    "Low Temp, 1A": ["B0045", "B0046", "B0047", "B0048"],
    "Low Temp, 2A": ["B0053", "B0054", "B0055", "B0056"],
    "Room Temp, 2A": ["B0005", "B0006","B0007", "B0018"],
    "Room Temp, PWM 50% 4A":  ["B0025", "B0026", "B0027", "B0028"],
    "High Temp, 4A": ["B0029", "B0030", "B0031", "B0032"]
    #"Low Temp, dynamic load": ["B0041", "B0042", "B0043", "B0044"]
    #"Room Temp, 2A": "B0007"
    #"Room Temp, PWM 50% 4A": 
}

# -------- Einheitliche Degradation-Visualisierung mit Debugging --------
from plotly.subplots import make_subplots

# Berechne und lade alle Re-Traces und Kapazitätsdaten einmal

# Re-Traces pro Gruppe
all_re_traces = []
re_traces_per_group = []
for i, (grp, ids) in enumerate(gruppen.items()):
    group_count = 0
    for bid in ids:
        d = df_for_re[df_for_re["battery_id"] == bid].copy()
       #print(f"DEBUG: Loaded Re data for Group = {grp}, Battery ID = {bid}, Rows = {len(d)}")
        series = d["Re"].interpolate(method='linear', limit_direction='both')
        smooth_re = series.rolling(window=20, center=True, min_periods=1).mean()
        all_re_traces.append(go.Scatter(
            x=d["test_id"], y=smooth_re, name=f"{bid} (Re)",
            visible=(i == 0),
            hoverlabel=dict(bgcolor="#dddddd",
                            font=dict(color="#000", family="Courier New", size=12),
                            namelength=-1),
            hovertemplate="Zyklus %{x}<br>Re %{y:.4f} Ω"
        ))
        group_count += 1
    re_traces_per_group.append(group_count)

# Debug: Zeige alle eindeutigen Werte in df["type"]
##print("DEBUG: Einzigartige Werte in df['type']:", df["type"].unique())
# Debug: Zeige alle Spaltennamen im DataFrame
#print("DEBUG: Spalten in df:", df.columns.tolist())
#print("DEBUG: Nicht-NA-Anzahl in 'Capacity_Ah':", df["Capacity_Ah"].notna().sum())
# Kapazitätsdaten vorbereiten und Traces pro Gruppe

if df["Capacity_Ah"].notna().sum() == 0:
   #print("WARNUNG: Keine Kapazitätsdaten verfügbar, überspringe Kapazitäts-Plot.")
    # Erzeuge nur Re-Plot ohne Kapazitäts-Subplot
    fig = go.Figure(data=all_re_traces)
    buttons_re = []
    total_re_traces = sum(re_traces_per_group)
    for i, grp in enumerate(gruppen.keys()):
        vis_re = [False] * total_re_traces
        start_re = sum(re_traces_per_group[:i])
        for j in range(re_traces_per_group[i]):
            vis_re[start_re + j] = True
        buttons_re.append(dict(
            label=grp,
            method="update",
            args=[{"visible": vis_re},
                  {"title.text": f"Re-Verlauf – {grp}"}]
        ))
    fig.update_layout(
        updatemenus=[dict(
            active=0,
            buttons=buttons_re,
            direction="down",
            x=0.7, y=1.25,
            xanchor="left", yanchor="top",
            bgcolor="#1e1e1e",
            bordercolor="#444",
            borderwidth=1,
            font=dict(family="Courier New", size=14, color="#f0f0f0")
        )],
        template="plotly_dark",
        font=dict(family="Courier New", size=14, color="#f0f0f0"),
        colorway=["#e41a1c", "#377eb8", "#4daf4a", "#984ea3"],
        paper_bgcolor="#1e1e1e",
        plot_bgcolor="#1e1e1e",
        height=1000,  # vergrössert Plotbereich, Y‑Achsen erscheinen ~ doppelt so hoch
        width=760,
        margin=dict(l=50, r=160, t=40, b=100),
        xaxis=dict(
            title=dict(
                text="Zyklen",
                font=dict(family="Courier New", size=16, color="#f0f0f0")
            ),
            tickfont=dict(family="Courier New", size=14, color="#f0f0f0"),
            title_standoff=20
        ),
        yaxis=dict(
            title=dict(
                text="Re (Ω)",
                font=dict(family="Courier New", size=16, color="#f0f0f0")
            ),
            tickfont=dict(family="Courier New", size=14, color="#f0f0f0")
        ),
        legend=dict(
            title="Akkuzellen",
            orientation="v",
            x=1.02,
            y=1,
            font=dict(family="Courier New", size=12, color="#f0f0f0"),
            bgcolor="#1e1e1e"
    ),
    hovermode="x unified"
    )
    fig.show()
else:
    df_discharge = df[df["Capacity_Ah"].notna()].copy()
    df_discharge["Capacity_Ah"] = pd.to_numeric(df_discharge["Capacity_Ah"], errors="coerce")
    df_discharge = df_discharge.dropna(subset=["Capacity_Ah"])
    df_discharge = df_discharge.sort_values(by=["battery_id", "test_id"])

    valid_ids_dict = {}

    all_cap_traces = []
    cap_traces_per_group = []
    for i, (grp, ids) in enumerate(gruppen.items()):
        valid_ids = [bid for bid in ids if not df_discharge[df_discharge["battery_id"] == bid].empty]
        valid_ids_dict[grp] = valid_ids
        group_count = 0
        for bid in valid_ids:
            d_cap = df_discharge[df_discharge["battery_id"] == bid].copy()
           #print(f"DEBUG: Loaded Capacity data for Group = {grp}, Battery ID = {bid}, Rows = {len(d_cap)}")
            # Finde den ersten Zyklus mit positiver Kapazität
            positive_cycles = d_cap[d_cap["Capacity_Ah"] > 0]
            if positive_cycles.empty:
               #print(f"DEBUG: Keine positiven Kapazitätsdaten für {bid}, skip.")
                pass
            first_cycle = positive_cycles["test_id"].min()
            cap0 = d_cap.loc[d_cap["test_id"] == first_cycle, "Capacity_Ah"].iloc[0]
            if cap0 < 0.05:
               #print(f"DEBUG: cap0 ({cap0:.3f} Ah) zu klein – {bid} übersprungen.")
                pass
           #print(f"DEBUG: cap0 for {bid} (first positive cycle {first_cycle}) = {cap0}")
            d_cap["cap_pct"] = d_cap["Capacity_Ah"] / cap0 * 100
            smooth_cap = d_cap["cap_pct"].rolling(window=10, center=True, min_periods=1).mean()
            all_cap_traces.append(go.Scatter(
                x=d_cap["test_id"], y=smooth_cap, name=f"{bid} (Kap)",
                visible=(i == 0),
                hoverlabel=dict(bgcolor="#dddddd",
                                font=dict(color="#000", family="Courier New", size=12),
                                namelength=-1),
                hovertemplate="Zyklus %{x}<br>Kapazität %{y:.1f}%"
            ))
            group_count += 1
        cap_traces_per_group.append(group_count)

    # Debug-Ausgabe: Zeige valid_ids pro Gruppe und Anzahl von Kapazitätstraces
   #print(f"DEBUG: valid_ids_dict = {valid_ids_dict}")
   #print(f"DEBUG: cap_traces_per_group = {cap_traces_per_group}")
   #print(f"DEBUG: total_cap_traces = {sum(cap_traces_per_group)}")

    # Erstelle Subplots: Re oben (Row 1), Kapazität unten (Row 2)
    fig_combo = make_subplots(rows=2, cols=1,
        shared_xaxes=True,
        vertical_spacing=0.08,
        subplot_titles=("Re-Verlauf", "Kapazitäts-Verlauf")
    )

    # Füge alle Traces hinzu
    for trace in all_re_traces:
        fig_combo.add_trace(trace, row=1, col=1)
    for trace in all_cap_traces:
        fig_combo.add_trace(trace, row=2, col=1)

    # Erzeuge Dropdown-Buttons
    buttons = []
    total_re_traces = sum(re_traces_per_group)
    total_cap_traces = sum(cap_traces_per_group)
    total_traces = total_re_traces + total_cap_traces

    for i, grp in enumerate(gruppen.keys()):
        vis = [False] * total_traces
        # Re-Traces dieser Gruppe sichtbar schalten
        start_re = sum(re_traces_per_group[:i])
        for j in range(re_traces_per_group[i]):
            vis[start_re + j] = True
        # Kapazitäts-Traces dieser Gruppe sichtbar schalten
        start_cap = total_re_traces + sum(cap_traces_per_group[:i])
        for j in range(cap_traces_per_group[i]):
            vis[start_cap + j] = True

        buttons.append(dict(
            label=grp,
            method="update",
            args=[
                {"visible": vis},
                {"title.text": f"Degradation – {grp}"}
            ]
        ))

    # Layout anpassen
    fig_combo.update_layout(
        updatemenus=[dict(
            active=next((idx for idx,c in enumerate(cap_traces_per_group) if c>0), 0),
            buttons=buttons,
            direction="down",
            xanchor="left", yanchor="top",
            x=0.7, y=1.13,
            pad={"r": 10, "t": 10},
            showactive=True,
            bgcolor="#1e1e1e",
            bordercolor="#444",
            borderwidth=1,
            font=dict( family="Courier New", size=14, color="rgb(116, 113, 113) ")
        )],
        template="plotly_dark",
        height=1000,  # grösserer Plotbereich – Y‑Achsen ~ doppelte Höhe
        width=760,
        font=dict(family="Courier New", size=14, color="#f0f0f0"),
        paper_bgcolor="#1e1e1e",
        plot_bgcolor="#1e1e1e",
        margin=dict(l=60,r=180,t=120,b=150),
        legend=dict(
            title="Akkuzellen",
            orientation="v",
            x=1.02,
            y=1,
            font=dict(family="Courier New", size=12, color="#f0f0f0"),
            bgcolor="#1e1e1e"),
        title="Degradation – Low Temp, 1A",
        xaxis=dict(title="Zyklen", autorange=True ),
        xaxis2=dict(title="Zyklen", autorange=True ),
        yaxis=dict(title="Re (Ω)", range=[0.03, 0.15], fixedrange=True),
        yaxis2=dict(title="Kapazität (%)", rangemode="nonnegative", fixedrange=True),
        hovermode="x unified",
        showlegend=True
    )

fig_combo.show()
Code
import numpy as np
from scipy.stats import median_abs_deviation, theilslopes

def slope_per_cell(df_cell):
    """Robuste Theil-Sen-Schätzung auf eine Einzelzelle."""
    # Berechne geglättete Re und filtere gültige Werte
    s = df_cell["Re"].rolling(5, center=True).mean()
    mask = s.notna()
    x = df_cell["test_id"].values[mask]
    y = s[mask].values
    # Theil-Sen Schätzung auf gefilterten Daten
    slope, intercept, _, _ = theilslopes(y, x, alpha=0.80)
    return slope, intercept


# Inverse Zuordnung: Batterie-ID → Testgruppe
inv_map = {cell: grp for grp, ids in gruppen.items() for cell in ids}
if not skip_training:
    results = []
    for bid, d in df_for_re.groupby("battery_id"):
        # Überspringe Zellen, die nicht in den definierten Gruppen sind
        if bid not in inv_map:
            continue

        # Berechne Steigung und Achsenabschnitt
        slope, intercept = slope_per_cell(d)

        # Filtere die ersten 3 Test-IDs auf valide Re-Werte
        first_cycles = d[d["test_id"] <= d["test_id"].min() + 2]
        re_vals = pd.to_numeric(first_cycles["Re"], errors="coerce").dropna()

        if re_vals.empty:
           #print(f"DEBUG: Keine gültigen Re-Werte für Initialzyklus bei {bid}, skip.")
            pass

        # Bestimme re0 als Median dieser ersten Re-Werte
        re0 = re_vals.median()

        # Last Re: nimm das letzte nicht-NaN-Re aus d
        re_non_nan = pd.to_numeric(d["Re"], errors="coerce").dropna()
        if re_non_nan.empty:
           #print(f"DEBUG: Keine validen Re-Werte insgesamt für {bid}, skip.")
            pass
        last_re = re_non_nan.iloc[-1]

        # Relative Veränderung
        rel = (last_re - re0) / re0

        group = inv_map[bid]
        results.append({
            "battery_id": bid,
            "group": group,
            "slope": slope,
            "rel_incr": rel
        })

    # Baue DataFrame und aggregation
    trend_df = pd.DataFrame(results)

    # Falls ohne Ergebnisse: Abbruch oder Hinweis
    if trend_df.empty:
        pass
       #print("WARNUNG: Kein Datensatz für Trend-Analyse gefunden.")
    else:
        agg_df = trend_df.groupby("group").agg(
            mean_slope=("slope", "mean"),
            sd_slope=("slope", "std"),
            mean_rel_incr=("rel_incr", "mean"),
            sd_rel_incr=("rel_incr", "std"),
            count=("battery_id", "count")
        ).reset_index()
    #fig.show()

    import numpy as np

    # Aggregation: Mittelwert, SD und 95%-CI von slope und rel_incr pro Gruppe

    agg_df = trend_df.groupby("group").agg(
        mean_slope=("slope", "mean"),
        sd_slope=("slope", "std"),
        mean_rel_incr=("rel_incr", "mean"),
        sd_rel_incr=("rel_incr", "std"),
        count=("battery_id", "count")
    ).reset_index()
# -----------------------------------------
# Sicherstellen, dass cap_agg existiert:
try:
    cap_agg
except NameError:
    cap_agg = pd.DataFrame(columns=["group", "mean_slope", "sd_slope", "count"])
# -----------------------------------------

Modellierte Degradationsverläufe

  1. Was dargestellt wird

    • Obere Grafik: modelierter Verlauf des Innenwiderstands – steigt im Laufe der Zyklen.
    • Mittlere Grafik: Prognostizierter Kapazitätsverlauf – fällt typischerweise über die Lebensdauer.
    • Untere Grafik: Geschätzte Temperaturentwicklung – simuliert basierend auf Entladestrom, Innenwiderstand und Kapazität
  2. Interaktive Funktionen

    Element Aktion Effekt
    📌 Legende-Klick Einfach-Klick: Serie aus-/einblenden
    Doppelklick: Fokus auf eine Serie
    Übersicht oder Fokus
    🔍 Mouse-Over Zeige auf eine Linie Zeigt exakten Messwert (z. B. Temperatur in °C)
    ⚙️ Slider Start-Innenwiderstand anpassen Prognosekurve verschiebt sich
    📂 Dropdown Betriebsprofil wählen Zeigt Verlauf unter spezifischer Belastung
  3. Was verrät die Temperaturkurve?
    Mit zunehmendem Innenwiderstand steigt die Verlustleistung (I²R), was zu einer stärkeren Erwärmung der Zelle führt. Gleichzeitig nimmt die Kapazität ab, wodurch sich die Entladezeit verkürzt. Die resultierende Temperaturkurve zeigt deshalb typischerweise einen Wärmegipfel im mittleren Lebensabschnitt der Zelle, ein kritischer Punkt für Alterung und thermisches Management.

  4. Interpretationshinweis
    Akkuzellen gelten oft als verbraucht, sobald ihre Kapazität unter 70 % des Ursprungswerts fällt. Doch könnte ein Produkt mit leicht reduzierter Laufzeit nicht trotzdem noch sinnvoll einsetzbar sein? Diese Frage bietet Raum für neue Produktstrategien.

Code
# ---------- interactive prediction tool (Re + Kap + Tmax) --------------------
from plotly.subplots import make_subplots
from scipy.stats import theilslopes
import numpy as np

# ---------- 2.1 Vorbereitung -------------------------------------------------
# Lookup‑Tables
re_lookup  = agg_df.set_index("group")[["mean_slope", "sd_slope"]]
cap_lookup = cap_agg.set_index("group")[["mean_slope", "sd_slope"]]

groups_list = list(agg_df["group"])  # Reihenfolge beibehalten

# Max‑Zykluslänge je Gruppe (bis Kap‑Prognose 0 % erreicht)
max_cycles_dict = {}
for grp in groups_list:
    mu_cap = cap_lookup.loc[grp, "mean_slope"] if grp in cap_lookup.index else 0
    max_cycles_dict[grp] = int(np.ceil(-100/mu_cap))+50 if mu_cap < 0 else 600
global_max = max(max_cycles_dict.values())
cycles = np.arange(0, global_max + 1)

# Re₀‑Sliderwerte
re0_values  = np.linspace(0.04, 0.12, 8)

# ---------- 2.2 Vorberechnen aller Kurven -----------------------------------
re_pred,  re_band  = [], []
cap_pred, cap_band = [], []

# Geometrie & Materialparameter (Edelstahl‑Zelle Ø18 mm × 65 mm, 45 g)
d       = 0.018      # [m]
h       = 0.065      # [m]
A       = np.pi * d * h + 2 * np.pi * (d/2)**2  # Oberfläche [m²]
m_cell  = 0.045                              # Masse [kg]
c_p     = 900                                # spez. Wärmekapazität [J/kgK] 
#https://www.bundestag.de/resource/blob/848264/22e6ffcce55b5dbf54265cb3032bca81/WD-8-023-21-pdf.pdf?utm_source=chatgpt.com
alpha   = 12.0                               # Wärmeübergangskoeff. [W/m²K]
Tamb    = 25                                 # Umgebungstemp. [°C]

# Stromzuordnung je Gruppe
group_I = {
    "Low Temp, 1A":            1.0,
    "Low Temp, 2A":            2.0,
    "Room Temp, 2A":           2.0,
    "Room Temp, PWM 50% 4A":   2.0,   # ≈50 % Tastgrad
    "High Temp, 4A":           4.0
}

def Tmax_cycle(I,R,C):
    if np.isnan(R) or np.isnan(C) or C<=0: return np.nan
    P = I**2 * R
    t = (C/I)*3600
    dT_inf = P/(alpha*A)
    tau = m_cell*c_p/(alpha*A)
    return Tamb + dT_inf*(1-np.exp(-t/tau))

tmax_pred = []  # [grp][r0_idx] -> np.array

for grp in groups_list:
    mu_re, sd_re = re_lookup.loc[grp]
    mu_cap, sd_cap = cap_lookup.loc[grp] if grp in cap_lookup.index else (0,0)

    grp_re_pred, grp_re_band = [], []
    grp_tmax_pred = []

    # Kap‑Kurven (einmal je Gruppe)
    kp_raw = 100 + mu_cap*cycles
    kp = np.clip(kp_raw,0,None)
    kup = np.clip(100+(mu_cap+sd_cap)*cycles,0,None)
    klo = np.clip(100+(mu_cap-sd_cap)*cycles,0,None)
    cap_pred.append(kp);  cap_band.append(np.concatenate([kup,klo[::-1]]))

    I = group_I.get(grp,2)

    for r0 in re0_values:
        p  = r0 + mu_re*cycles
        up = r0 + (mu_re+sd_re)*cycles
        lo = r0 + (mu_re-sd_re)*cycles
        grp_re_pred.append(p)
        grp_re_band.append(np.concatenate([up,lo[::-1]]))

        # Tmax je Zyklus (Kap in % → Kap_Ah normiert auf 1 Ah @100 %)
        Tmax_vec = [Tmax_cycle(I,R,C/100) for R,C in zip(p,kp)]
        grp_tmax_pred.append(Tmax_vec)

    re_pred.append(grp_re_pred);  re_band.append(grp_re_band)
    tmax_pred.append(grp_tmax_pred)

# ---------- 2.3 Figure & Traces ---------------------------------------------
fig = make_subplots(rows=3, cols=1, shared_xaxes=True, vertical_spacing=0.07,
                    subplot_titles=("Re‑Prognose","Kapazitäts‑Prognose",
                                    "ΔT‑Prognose"))

traces_per_grp = 6  # Re‑Line, Re‑Band, Kap‑Line, Kap‑Band, Tmax‑Line, Tmax‑Band (Band optional)

for i, grp in enumerate(groups_list):
    vis = (i==0)

    # Re
    fig.add_trace(go.Scatter(x=cycles,y=re_pred[i][2],mode="lines",
                             name="Re (Ω)",visible=vis,
                             line=dict(width=3)),row=1,col=1)
    fig.add_trace(go.Scatter(x=np.concatenate([cycles,cycles[::-1]]),
                             y=re_band[i][2],fill="toself",
                             fillcolor="rgba(120,120,120,0.25)",
                             line=dict(color="rgba(0,0,0,0)"),
                             hoverinfo="skip", name="Re ±1 SD",
                             visible=vis),row=1,col=1)

    # Kap
    fig.add_trace(go.Scatter(x=cycles,y=cap_pred[i],mode="lines",
                             name="Kap (%)",visible=vis,
                             line=dict(width=3)),row=2,col=1)
    fig.add_trace(go.Scatter(x=np.concatenate([cycles,cycles[::-1]]),
                             y=cap_band[i],fill="toself",
                             fillcolor="rgba(80,120,200,0.25)",
                             line=dict(color="rgba(0,0,0,0)"),
                             hoverinfo="skip",name="Kap ±1 SD",
                             visible=vis),row=2,col=1)

    # Tmax (now ΔT)
    fig.add_trace(go.Scatter(
        x=cycles,
        y=np.array(tmax_pred[i][2]) - Tamb,   # ΔT = Tmax – Tamb
        mode="lines",
        name="ΔT (°C)",
        visible=vis,
        hovertemplate="Zyklus %{x}<br>ΔT %{y:.1f} °C",
        line=dict(width=3)
    ), row=3, col=1)
    # kein Band -> Dummy invisible trace für Konsistenz
    fig.add_trace(go.Scatter(x=[],y=[],visible=vis,showlegend=False),row=3,col=1)

# ---------- 2.4 Slider -------------------------------------------------------
steps=[]
for k,r0 in enumerate(re0_values):
    new_y=[]
    for i in range(len(groups_list)):
        new_y.extend([
            re_pred[i][k], re_band[i][k],
            cap_pred[i],
            cap_band[i],
            (np.array(tmax_pred[i][k]) - Tamb),
            []  # Dummy trace
        ])
    steps.append(dict(method="restyle", args=[{"y":new_y}], label=f"{r0:.2f} Ω"))
slider=[dict(active=2,currentvalue={"prefix":"Re₀: "},pad={"t":40},steps=steps)]

# ---------- 2.5 Dropdown -----------------------------------------------------
total_tr = traces_per_grp*len(groups_list)
buttons=[]
# --- 0. Beschreibungen für dynamische Textbox ---
group_descriptions = {
    "Low Temp, 1A":  "",
    "Low Temp, 2A":  "",
    "Room Temp, 2A": "",
    "Room Temp, PWM 50% 4A": "<span style='color:#ff5252; font-weight:bold;'>⚠️ Achtung: Schlechte Datenlage und atypische Entwicklung des Innenwiderstandes!</span>",
    "High Temp, 4A": ""
}

def make_desc_ann(txt):
    return dict(
        text=txt,
        x=0.0, y=1.17, xref="paper", yref="paper",
        xanchor="left", yanchor="top",
        showarrow=False,
        bgcolor="#1e1e1e",
        bordercolor="#444", borderwidth=1,
        font=dict(family="Courier New", size=12, color="#f0f0f0"),
        align="left",
        # Ensure HTML is interpreted (for plotly >=5.0, use 'text' with HTML if 'html' not supported)
        # For Plotly, set annotation 'text' to HTML and let Quarto render it as HTML-aware markdown
    )

# Initiale Annotation anzeigen
fig.add_annotation(make_desc_ann(group_descriptions[groups_list[0]]))

for j,grp in enumerate(groups_list):
    vis=[False]*total_tr
    start=j*traces_per_grp
    for t in range(traces_per_grp):
        vis[start+t]=True
    ann = [make_desc_ann(group_descriptions[grp])]
    buttons.append(dict(
        label=grp,
        method="update",
        args=[
            {"visible": vis},
            {
                "title.text": f"Prognosen – {grp}",
                "annotations": ann,
                "xaxis.range":  [0, max_cycles_dict[grp]],
                "xaxis2.range": [0, max_cycles_dict[grp]],
                "xaxis3.range": [0, max_cycles_dict[grp]]
            }
        ]
    ))

updatemenu=[dict(active=0,buttons=buttons,direction="down",
                 x=0.7,y=1.13,xanchor="left",yanchor="top",
                 bgcolor="#1e1e1e",bordercolor="#444",borderwidth=1,
                 pad={"r":10,"t":10},
                 font=dict(family="Courier New",size=14,color="rgb(116, 113, 113) "))]

# ---------- 2.6 Layout -------------------------------------------------------
fig.update_layout(sliders=slider,updatemenus=updatemenu,
    title="Prognosen – Low Temp, 1A",template="plotly_dark",
    height=1000, width=760, paper_bgcolor="#1e1e1e",plot_bgcolor="#1e1e1e",
    font=dict(family="Courier New",size=14,color="#f0f0f0"),
    margin=dict(l=60,r=180,t=120,b=150),
    hovermode="x unified")

# Fix initiale X-Achsenbereiche für alle drei Subplots
init_max = max_cycles_dict[groups_list[0]]
fig.update_xaxes(range=[0, init_max], row=1, col=1)
fig.update_xaxes(range=[0, init_max], row=2, col=1)
fig.update_xaxes(range=[0, init_max], row=3, col=1)

fig.update_xaxes(title_text="Zyklen",row=3,col=1)
fig.update_yaxes(title_text="Re (Ω)",row=1,col=1)
fig.update_yaxes(title_text="Kapazität (%)",row=2,col=1, rangemode="nonnegative")
fig.update_yaxes(title_text="ΔT (°C)", row=3, col=1)

fig.show()
# ---------- end interactive prediction tool ----------


Fazit: Lebensdaueranalyse & Wärmeentwicklung vereint

Die hier vorgestellte Kombination aus Theil-Sen-Regression und einem vereinfachten Newton-basierten Thermomodell liefert eine praxistaugliche Richtschnur für die Bewertung des gesamten Lebenszyklus einer Lithium-Ionen-Zelle. Bereits auf Basis einer sorgfältig bereinigten Auswahl an Messreihen ermöglicht dieser Ansatz eine aussagekräftige Einschätzung, wann die Kapazität unter den üblichen End-of-Life-Schwellenwert von 70 % fällt und zu welchem Zeitpunkt das thermische Belastungsmaximum zu erwarten ist.

Daraus ergibt sich ein klar definierter Handlungsrahmen:

  • Produktdesign: Kapazitätsreserven und Kühlsysteme können gezielt so ausgelegt werden, dass sie das prognostizierte thermische Belastungsmaximum zuverlässig abfangen.
  • Wartung & Second Life: Die modellierte Konvergenz von Kapazitätsrückgang und Temperaturspitze liefert fundierte Anhaltspunkte für Zustandsdiagnosen, Zelltausch oder eine gezielte Weiterverwendung in Anwendungen mit reduzierten Leistungsanforderungen.

Die datenbasierte Vorhersage ermöglicht eine planbare Alterungsstrategie, reduziert Sicherheitsrisiken und erschliesst gleichzeitig Potenziale zur Verlängerung der Erst- und Zweitnutzungsdauer. All diese Rückschlüsse können einen wesentlichen Beitrag zu nachhaltigeren Akkusystemen leisten.